Skip to content

feat(usage): token usage statistics — global page, per-task context capacity, live cache hit rate#95

Merged
cnjack merged 1 commit into
mainfrom
feat/usage-statistics
Jun 22, 2026
Merged

feat(usage): token usage statistics — global page, per-task context capacity, live cache hit rate#95
cnjack merged 1 commit into
mainfrom
feat/usage-statistics

Conversation

@cnjack

@cnjack cnjack commented Jun 22, 2026

Copy link
Copy Markdown
Owner

What

Adds usage statistics to jcode across TUI / Web / ACP, plus the underlying token-tracking refactor needed to make it accurate.

Global stats (Settings → 使用统计)

GET /api/usage/stats?days=N → tokens used, sessions, turns, active days, current/longest streak, most-used model, an activity heatmap (365d), a daily token trend, and per-model / per-project breakdowns. Rendered with hand-rolled SVG (no chart lib), orange theme preserved.

Per-task context capacity (composer ring + popup)

A context-fill ring on the composer shows the % of the context window in use (turns red ≥90%). Clicking it opens a popup breaking the window into Messages / System tools / MCP tools / Skills / System prompt + Free space, each as a share of the full window, plus the KV cache hit rate and the conversation's cumulative tokens. GET /api/tasks/{id}/stats.

  • Updates live per LLM call during a run (not just at turn end).
  • Seeded on resume (estimated from loaded history, refined by the next turn).
  • Hidden on the empty welcome screen.

Token tracking refactor (internal/model)

  • TokenUsage gains ReasoningTokens / CacheWriteTokens / CallCount; Add() takes an AddParams struct.
  • Capture tolerates providers that omit total_tokens (e.g. GLM) and records once per streamed call.
  • A context usage notifier drives the live UI without coupling the model layer to the web layer.
  • Cache hit rate = cached / prompt (the only provider-portable definition via go-openai; shows when caching is never reported).

Persistence (internal/usage)

Append-only event log at ~/.jcode/usage/events.jsonl (atomic O_APPEND, multi-process safe). All metrics are derived at read time by Aggregate. Subagent and teammate tokens roll up under the leader session.

Notes / limitations

  • cache_creation isn't available over the shared go-openai transport, so CacheWriteTokens stays 0 (future-proofed).
  • Cost isn't derived yet (ModelCost exists; a spend view is a follow-up).
  • See docs/usage-stats.md.

Testing

  • go build ./..., go vet ./..., gofmt — clean.
  • Unit tests: token struct (internal/model), aggregation/streaks (internal/usage), endpoints via in-process httptest (internal/web).
  • Frontend: vue-tsc + vite build pass.
  • Desktop: no Tauri/capability changes (endpoints are same-origin to the sidecar).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added comprehensive usage statistics tracking and reporting, aggregating token consumption across sessions with daily trends and cache hit rate metrics
    • Introduced context capacity visualization showing token breakdown by category (messages, system tools, MCP tools, skills, system prompt)
    • Added usage statistics panel in settings displaying activity heatmap, usage trends, streaks, cache metrics, and per-model/project breakdowns
    • Exposed new /api/usage/stats and /api/tasks/{id}/stats endpoints for programmatic access to usage data

…apacity, live cache hit rate

Adds usage statistics across TUI/Web/ACP:

- Token tracking refactor (internal/model): TokenUsage gains reasoning /
  cache-write / call-count; Add() takes AddParams; capture tolerates providers
  that omit total_tokens (e.g. GLM) and records once per streamed call. A
  context usage notifier pushes per-call updates for live UI.
- Cache hit rate = cached / prompt (provider-portable; shows "—" when the
  provider never reports caching).
- Append-only event log (~/.jcode/usage/events.jsonl) + read-time Aggregate for
  totals / streak / active-days / heatmap / by-model / by-project. Subagent and
  teammate tokens roll up under the leader session.
- Global stats page (Settings → Usage) + GET /api/usage/stats.
- Per-task context-capacity popup (Messages / System tools / MCP tools / Skills /
  System prompt + free space + cache hit rate) + GET /api/tasks/{id}/stats;
  a context-fill ring on the composer that updates live during a run and is
  seeded on resume.
- i18n (en / zh-Hans / zh-Hant / ja / ko), docs/usage-stats.md, unit + httptest
  coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a complete usage statistics system: a richer TokenUsage model with cache/reasoning fields, an append-only JSONL event store, per-turn token delta recording wired into the runner, team manager, and subagent, two new HTTP endpoints (/api/usage/stats, /api/tasks/{id}/stats), and frontend panels for a 53-week heatmap, per-task context-capacity popup, and a new Settings → Usage tab.

Changes

Usage Statistics System

Layer / File(s) Summary
Token model and notifier context expansion
internal/model/chatmodel.go, internal/model/token_ctx.go, internal/handler/handler.go, internal/handler/web.go, internal/model/token_usage_test.go
TokenUsage gains reasoning/cache-write/call-count fields, AddParams struct, GetFull/CacheHitRate/CacheObserved methods, and streaming is refactored to record usage exactly once. A per-context usage-notifier callback is added via token_ctx.go. handler.TokenUsage and WebTokenData are expanded to mirror the full model. Tests cover Add, CacheHitRate, Minus, and Reset.
Usage event store, aggregation, and config paths
internal/usage/event.go, internal/usage/stats.go, internal/usage/estimate.go, internal/config/config.go, internal/usage/usage_test.go
Introduces Event/Store (append-only JSONL at ~/.jcode/usage/events.jsonl) with Record/Load. Adds Aggregate computing totals, streak metrics, cache hit rate, and model/project shares. ContextBreakdown/EstimateBytes provide token-count estimation. Config path helpers UsageDir/UsageEventsPath are added. Full test suite covers store I/O, aggregation, and streak logic.
Per-turn delta recording in runner, team, and subagent
internal/runner/runner.go, internal/team/manager.go, internal/tools/subagent.go, internal/session/session.go, internal/telemetry/langfuse.go
runner.Run snapshots usage at turn start, installs a mid-run notifier, and calls recordUsageTurn to persist deltas. team/manager.go rolls teammate deltas into the leader session's event log. tools/subagent.go emits a usage event at run end. session.Recorder gains Project/Provider/Model accessors. Langfuse gains reasoning_tokens metadata.
Web server usage API endpoints and status enrichment
internal/web/server.go, internal/web/usage.go, internal/command/web.go, internal/web/usage_test.go
Server gains usageStore and breakdownFn fields; GET /api/usage/stats and GET /api/tasks/{id}/stats are registered. handleUsageStats aggregates events with heatmap lookback; handleTaskStats returns live context breakdown or historical totals. handleStatus is enriched with a live token snapshot. estimateToolTokens and breakdownFn are wired from command/web.go. HTTP handler tests cover both endpoints.
Frontend types, API client, and Pinia stores
web/src/types/api.ts, web/src/composables/api.ts, web/src/stores/usage.ts, web/src/stores/chat.ts
TokenUpdateData gains cache/reasoning fields; UsageStats/TaskStats/TaskContextBreakdown interfaces are added. api.usageStats/api.taskStats methods are wired. New useUsageStore manages global stats and per-task stats with fetchStats/fetchTaskStats actions. useChatStore adds cacheHitPercentage, health-seeded tokenInfo, and session-load token estimation.
Frontend UI: context-capacity popup, stats panel, and settings
web/src/components/ChatInput.vue, web/src/components/ContextCapacityPopup.vue, web/src/components/UsageStatsPanel.vue, web/src/components/SettingsDialog.vue
ChatInput.vue replaces the token display with a clickable SVG ring that toggles ContextCapacityPopup. ContextCapacityPopup.vue shows a stacked token-category bar, per-bucket rows, and cache hit rate. UsageStatsPanel.vue renders a 53-week heatmap, daily trend bars, and model/project breakdowns. SettingsDialog.vue adds a new Usage tab wired to the panel.
i18n locales and documentation
web/src/i18n/locales/*.ts, docs/usage-stats.md
Adds settings.tabs.usage, settings.usageStats, and settings.contextCapacity locale keys across en, ja, ko, zh-Hans, and zh-Hant. docs/usage-stats.md specifies the token model, JSONL event format, HTTP API surface, context breakdown buckets, and known limitations.

Sequence Diagrams

sequenceDiagram
  rect rgba(100, 149, 237, 0.5)
    Note over runner.Run,usage.Store: Per-turn recording (backend)
    runner.Run->>runner.Run: snapshot startUsage
    runner.Run->>model.WithUsageNotifier: install notifier fn in ctx
    model.recordUsage->>handler.OnTokenUpdate: mid-run token update
    runner.Run->>runner.recordUsageTurn: tracker + startUsage + recorder
    runner.recordUsageTurn->>usage.Store: RecordEvent(delta Event)
  end

  rect rgba(144, 238, 144, 0.5)
    Note over Browser,internal/web/usage.go: Stats API fetch (frontend → backend)
    Browser->>internal/web/usage.go: GET /api/usage/stats?days=30
    internal/web/usage.go->>usage.Store: Load(since)
    usage.Store-->>internal/web/usage.go: []Event
    internal/web/usage.go->>usage.Aggregate: Aggregate(events, today)
    usage.Aggregate-->>Browser: JSON{totals, heatmap, by_model…}

    Browser->>internal/web/usage.go: GET /api/tasks/{id}/stats
    internal/web/usage.go->>breakdownFn: ContextBreakdown (if active)
    internal/web/usage.go-->>Browser: JSON{is_active, context, cache…}
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • cnjack/jcode#13: Modifies internal/command/web.go runWebServer and internal/web/server.go route/handler configuration, overlapping directly with this PR's new route registration and ServerConfig field wiring.
  • cnjack/jcode#52: Extends token-usage accounting in internal/model/chatmodel.go for cached token counts and adds them to internal/telemetry/langfuse.go metadata, the same files this PR significantly refactors.

Poem

🐇 Hop hop, the tokens are counted at last,
Each prompt and completion logged into the past.
A heatmap of green squares, a streak to admire,
Cache hit rates glowing like embers of fire.
The context ring spins with a circular delight —
Little rabbit tracks every token tonight! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: adding comprehensive token usage statistics with global page, per-task context capacity tracking, and live cache hit rate monitoring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/usage-statistics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (2)
internal/web/usage_test.go (1)

172-212: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a regression test for historical-store load failures.

Please add a case where handleTaskStats hits a Store.Load error path and assert the HTTP behavior (status/body). This prevents future regressions where load failures quietly surface as empty stats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/usage_test.go` around lines 172 - 212, Add a test case to verify
error handling when the usage Store.Load operation fails. Create a scenario
where the Store encounters a load error (by mocking or stubbing the Store to
return an error), call handleTaskStats with this failing store, and then assert
that the HTTP response returns an appropriate error status code and error
details in the response body to prevent silent failures on load errors.
internal/usage/event.go (1)

92-98: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Wrap filesystem errors with operation/path context.

These paths currently return raw errors, which makes diagnosing stats-store failures harder at call sites. Wrap them with %w and include the failing operation/path.

Suggested patch
 import (
 	"bufio"
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"os"
 	"path/filepath"
 	"sync"
 	"time"
@@
 	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
-		return err
+		return fmt.Errorf("usage_store mkdir %s: %w", filepath.Dir(s.path), err)
 	}
 	f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 	if err != nil {
-		return err
+		return fmt.Errorf("usage_store open %s: %w", s.path, err)
 	}
@@
 	f, err := os.Open(s.path)
 	if err != nil {
 		if os.IsNotExist(err) {
 			return nil, nil
 		}
-		return nil, err
+		return nil, fmt.Errorf("usage_store open %s: %w", s.path, err)
 	}

As per coding guidelines, Use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code.

Also applies to: 111-117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/event.go` around lines 92 - 98, The os.MkdirAll and
os.OpenFile error returns lack operation and path context, making it difficult
to diagnose stats-store failures. Wrap both error returns using fmt.Errorf with
the %w verb to include context about the failing operation and the path being
accessed (s.path). For the os.MkdirAll error, describe the directory creation
failure with the path, and for the os.OpenFile error, describe the file opening
failure with the path. Apply the same wrapping pattern to the similar filesystem
operations around lines 111-117 to ensure consistent error handling throughout.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/command/web.go`:
- Around line 444-473: The breakdownFn closure reads shared mutable state
(systemPrompt, mcpTools, currentCM, and skillLoader) without synchronization,
creating a data race risk in the concurrent web server environment. Protect
access to these shared variables by acquiring an appropriate synchronization
lock (such as a mutex) before reading them within the breakdownFn function, and
release the lock after gathering all necessary data for the breakdown
calculation. Ensure that the entire set of reads happens atomically while
holding the lock to prevent torn or inconsistent state from being observed
during concurrent project switches or MCP reloads.
- Around line 449-471: The breakdownFn function always estimates context tokens
using systemPrompt and buildAllTools(currentCM), but it should dynamically
select between systemPrompt with buildAllTools(currentCM) for normal mode versus
planPrompt with buildPlanTools() for plan mode. Update the breakdownFn function
to check if plan mode is active and use the appropriate prompt and tools
building function to accurately reflect the actual context being used by the
agent in the current mode. This will ensure b.SystemPromptTokens and
b.SystemToolsTokens are correctly calculated regardless of whether plan mode is
enabled.

In `@internal/config/config.go`:
- Around line 455-458: The fmt.Errorf call in the error handling block after
os.UserHomeDir() is not following the project convention for wrapped errors.
Replace the current free-form error message "failed to get home directory: %w"
with the standard format "tool_name: %w" (where tool_name is the appropriate
identifier for this module), maintaining the wrapped error pattern with %w and
the captured err variable.

In `@internal/model/chatmodel.go`:
- Around line 161-164: The CacheObserved() method in TokenUsage incorrectly uses
CachedTokens count as the indicator for whether cache support was detected, but
this conflates actual cache hits with cache reporting capability. A provider may
report caching metadata but have zero cache hits in a session, which should
still count as cache being observed. Refactor CacheObserved() to check a
separate flag or counter that tracks whether caching information was ever
reported by the provider, independent of the actual cached token count. Also
apply the same fix to any related cache observation logic in the 280-301 line
range, ensuring all cache support detection is based on whether the provider
reports caching data rather than on whether cached tokens are greater than zero.

In `@internal/team/manager.go`:
- Around line 757-775: The usage.Event being recorded in the token delta
tracking is missing proper population of the Model and Project fields. The Model
field relies on state.Model which can be empty on the default-model path, and
Project is not set at all, causing analytics to drop these records from by_model
and by_project breakdowns. Enhance the usage.RecordEvent call by ensuring Model
has a fallback value when state.Model is empty and add the Project field to the
usage.Event struct with the appropriate project information, likely obtained
from the manager's dependencies or team state context.

In `@internal/tools/subagent.go`:
- Around line 351-365: The event recording in the RecordEvent call is being
gated by a check that requires d.TotalTokens to be greater than zero, which
prevents recording valid usage data from providers that omit the total_tokens
field while still reporting other token values like prompt or completion tokens.
Remove or modify the if d.TotalTokens > 0 condition to instead check if any of
the individual token fields (PromptTokens, CompletionTokens, CachedTokens,
ReasoningTokens, CacheWriteTokens, or CallCount) have non-zero values, ensuring
that usage events are recorded whenever there is meaningful token usage data
available regardless of whether TotalTokens is present.

In `@internal/usage/stats.go`:
- Around line 93-103: The CacheSupported flag in the aggregation logic is being
set based on whether Cached tokens are greater than zero, but it should instead
be determined by whether cache fields are actually reported by the provider,
regardless of hit count. Remove the current logic that sets CacheSupported =
agg.Totals.Cached > 0 and implement an explicit tracking mechanism (such as a
boolean field that is OR-aggregated across all events) to record whether cache
fields were observed in the data, then use that aggregated signal to determine
CacheSupported instead of inferring it from the Cached value.
- Around line 170-191: The longestStreak function initializes the best variable
to 1, which causes it to return a non-existent streak of length 1 when all date
parsing fails and the dates slice is empty. Change the initialization of the
best variable from 1 to 0 so that when the dates slice is empty (meaning all day
keys failed to parse), the loop does not execute and the function correctly
returns 0 instead of reporting a false streak.

In `@internal/web/usage.go`:
- Around line 133-134: The store.Load function call is ignoring its error return
value using a blank identifier, which masks I/O and corruption problems. Instead
of discarding the error with _, check the error returned from store.Load("") and
handle it appropriately by either logging the error and returning early or
implementing suitable error recovery logic that prevents the function from
continuing with uninitialized or incorrect data.

In `@web/src/components/ChatInput.vue`:
- Around line 29-36: The ctxRingColor computed property uses a hardcoded hex
color value `#E24B4A` for the warning state when token percentage is >= 90, which
violates the color-token contract. Replace this hardcoded hex value with an
appropriate CSS custom property (design token) from src/styles/tokens.css that
represents a warning or error state color. Update the ctxRingColor computed
property to reference this design token using var() syntax instead of the
literal hex value.

In `@web/src/components/ContextCapacityPopup.vue`:
- Line 144: The box-shadow property in ContextCapacityPopup.vue contains a
hardcoded RGBA color fallback value (rgba(0, 0, 0, 0.16)) which violates the
tokenized theme contract. Replace the hardcoded rgba color value in the
box-shadow fallback with a CSS custom property token defined in
src/styles/tokens.css. If a suitable shadow token does not already exist, add a
new token to the tokens.css file that defines the appropriate shadow value, then
reference that token in the var() function fallback instead of the hardcoded
color.

In `@web/src/components/UsageStatsPanel.vue`:
- Around line 141-145: The hardcoded "tokens" unit string in the cellTitle
function is not localized and will remain in English regardless of locale
changes. Replace the hardcoded "tokens" string with a translation reference
using the i18n pattern (similar to how `t('settings.usageStats.turnsUnit')` is
used for the turns unit). Create a new i18n key like
`settings.usageStats.tokensUnit` and update both the cellTitle function and the
other locations mentioned in lines 151-153 to use this localization key instead
of the hardcoded English string.

In `@web/src/stores/usage.ts`:
- Around line 18-25: In the fetchTaskStats function, the previous taskStats
value remains visible until the new API request completes, causing stale data to
display when switching sessions. To fix this, clear the taskStats value to null
immediately after setting taskLoading.value to true and before making the
api.taskStats call, ensuring no stale data is shown while the new session's data
is being fetched.

---

Nitpick comments:
In `@internal/usage/event.go`:
- Around line 92-98: The os.MkdirAll and os.OpenFile error returns lack
operation and path context, making it difficult to diagnose stats-store
failures. Wrap both error returns using fmt.Errorf with the %w verb to include
context about the failing operation and the path being accessed (s.path). For
the os.MkdirAll error, describe the directory creation failure with the path,
and for the os.OpenFile error, describe the file opening failure with the path.
Apply the same wrapping pattern to the similar filesystem operations around
lines 111-117 to ensure consistent error handling throughout.

In `@internal/web/usage_test.go`:
- Around line 172-212: Add a test case to verify error handling when the usage
Store.Load operation fails. Create a scenario where the Store encounters a load
error (by mocking or stubbing the Store to return an error), call
handleTaskStats with this failing store, and then assert that the HTTP response
returns an appropriate error status code and error details in the response body
to prevent silent failures on load errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f5cd36c4-c6e4-453a-a522-bf3652becabb

📥 Commits

Reviewing files that changed from the base of the PR and between c23b41c and 8222ae0.

📒 Files selected for processing (33)
  • docs/usage-stats.md
  • internal/command/web.go
  • internal/config/config.go
  • internal/handler/handler.go
  • internal/handler/web.go
  • internal/model/chatmodel.go
  • internal/model/token_ctx.go
  • internal/model/token_usage_test.go
  • internal/runner/runner.go
  • internal/session/session.go
  • internal/team/manager.go
  • internal/telemetry/langfuse.go
  • internal/tools/subagent.go
  • internal/usage/estimate.go
  • internal/usage/event.go
  • internal/usage/stats.go
  • internal/usage/usage_test.go
  • internal/web/server.go
  • internal/web/usage.go
  • internal/web/usage_test.go
  • web/src/components/ChatInput.vue
  • web/src/components/ContextCapacityPopup.vue
  • web/src/components/SettingsDialog.vue
  • web/src/components/UsageStatsPanel.vue
  • web/src/composables/api.ts
  • web/src/i18n/locales/en.ts
  • web/src/i18n/locales/ja.ts
  • web/src/i18n/locales/ko.ts
  • web/src/i18n/locales/zh-Hans.ts
  • web/src/i18n/locales/zh-Hant.ts
  • web/src/stores/chat.ts
  • web/src/stores/usage.ts
  • web/src/types/api.ts

Comment thread internal/command/web.go
Comment on lines +444 to +473
// breakdownFn estimates how the live agent's context window is partitioned
// across system prompt / built-in tools / MCP tools / skills. It reads the
// captured assembly variables (systemPrompt, mcpTools, currentCM, skillLoader)
// by reference, so project switches and MCP reloads are reflected without any
// cache to invalidate. Built-in tools = all tools minus MCP tools.
breakdownFn := func() usage.ContextBreakdown {
var b usage.ContextBreakdown
skillDesc := skillLoader.Descriptions()
b.SkillsTokens = usage.Estimate(skillDesc)
// Skills are injected into the system prompt, so subtract to avoid
// double-counting them in the system-prompt bucket.
b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens
if b.SystemPromptTokens < 0 {
b.SystemPromptTokens = 0
}
for _, mt := range mcpTools {
b.MCPToolsTokens += estimateToolTokens(ctx, mt)
}
if currentCM != nil {
total := 0
for _, at := range buildAllTools(currentCM) {
total += estimateToolTokens(ctx, at)
}
b.SystemToolsTokens = total - b.MCPToolsTokens
if b.SystemToolsTokens < 0 {
b.SystemToolsTokens = 0
}
}
return b
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

breakdownFn reads shared mutable state without synchronization.

This closure reads systemPrompt, mcpTools, currentCM, and mode-dependent tool composition while other request paths mutate those values (project switch, MCP reload, agent rebuild). In the web server’s concurrent request model, this is a data-race risk and can return torn/unstable breakdowns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/command/web.go` around lines 444 - 473, The breakdownFn closure
reads shared mutable state (systemPrompt, mcpTools, currentCM, and skillLoader)
without synchronization, creating a data race risk in the concurrent web server
environment. Protect access to these shared variables by acquiring an
appropriate synchronization lock (such as a mutex) before reading them within
the breakdownFn function, and release the lock after gathering all necessary
data for the breakdown calculation. Ensure that the entire set of reads happens
atomically while holding the lock to prevent torn or inconsistent state from
being observed during concurrent project switches or MCP reloads.

Comment thread internal/command/web.go
Comment on lines +449 to +471
breakdownFn := func() usage.ContextBreakdown {
var b usage.ContextBreakdown
skillDesc := skillLoader.Descriptions()
b.SkillsTokens = usage.Estimate(skillDesc)
// Skills are injected into the system prompt, so subtract to avoid
// double-counting them in the system-prompt bucket.
b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens
if b.SystemPromptTokens < 0 {
b.SystemPromptTokens = 0
}
for _, mt := range mcpTools {
b.MCPToolsTokens += estimateToolTokens(ctx, mt)
}
if currentCM != nil {
total := 0
for _, at := range buildAllTools(currentCM) {
total += estimateToolTokens(ctx, at)
}
b.SystemToolsTokens = total - b.MCPToolsTokens
if b.SystemToolsTokens < 0 {
b.SystemToolsTokens = 0
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Context breakdown currently misreports in plan mode.

breakdownFn always estimates from systemPrompt + buildAllTools(currentCM), but the active agent in plan mode uses planPrompt + buildPlanTools(). This overstates static context usage and can skew messages_tokens/capacity UI while in plan mode.

Suggested fix
 breakdownFn := func() usage.ContextBreakdown {
   var b usage.ContextBreakdown
+  prompt := systemPrompt
+  var toolsForMode []tool.BaseTool
+  if currentCM != nil {
+    if currentPlanMode {
+      prompt = planPrompt
+      toolsForMode = buildPlanTools()
+    } else {
+      toolsForMode = buildAllTools(currentCM)
+    }
+  }

   skillDesc := skillLoader.Descriptions()
-  b.SkillsTokens = usage.Estimate(skillDesc)
+  if !currentPlanMode {
+    b.SkillsTokens = usage.Estimate(skillDesc)
+  }

-  b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens
+  b.SystemPromptTokens = usage.Estimate(prompt) - b.SkillsTokens
   if b.SystemPromptTokens < 0 { b.SystemPromptTokens = 0 }

-  for _, mt := range mcpTools {
-    b.MCPToolsTokens += estimateToolTokens(ctx, mt)
-  }
-  if currentCM != nil {
+  if !currentPlanMode {
+    for _, mt := range mcpTools {
+      b.MCPToolsTokens += estimateToolTokens(ctx, mt)
+    }
+  }
+  if len(toolsForMode) > 0 {
     total := 0
-    for _, at := range buildAllTools(currentCM) {
+    for _, at := range toolsForMode {
       total += estimateToolTokens(ctx, at)
     }
     b.SystemToolsTokens = total - b.MCPToolsTokens
     if b.SystemToolsTokens < 0 { b.SystemToolsTokens = 0 }
   }
   return b
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
breakdownFn := func() usage.ContextBreakdown {
var b usage.ContextBreakdown
skillDesc := skillLoader.Descriptions()
b.SkillsTokens = usage.Estimate(skillDesc)
// Skills are injected into the system prompt, so subtract to avoid
// double-counting them in the system-prompt bucket.
b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens
if b.SystemPromptTokens < 0 {
b.SystemPromptTokens = 0
}
for _, mt := range mcpTools {
b.MCPToolsTokens += estimateToolTokens(ctx, mt)
}
if currentCM != nil {
total := 0
for _, at := range buildAllTools(currentCM) {
total += estimateToolTokens(ctx, at)
}
b.SystemToolsTokens = total - b.MCPToolsTokens
if b.SystemToolsTokens < 0 {
b.SystemToolsTokens = 0
}
}
breakdownFn := func() usage.ContextBreakdown {
var b usage.ContextBreakdown
prompt := systemPrompt
var toolsForMode []tool.BaseTool
if currentCM != nil {
if currentPlanMode {
prompt = planPrompt
toolsForMode = buildPlanTools()
} else {
toolsForMode = buildAllTools(currentCM)
}
}
skillDesc := skillLoader.Descriptions()
if !currentPlanMode {
b.SkillsTokens = usage.Estimate(skillDesc)
}
// Skills are injected into the system prompt, so subtract to avoid
// double-counting them in the system-prompt bucket.
b.SystemPromptTokens = usage.Estimate(prompt) - b.SkillsTokens
if b.SystemPromptTokens < 0 {
b.SystemPromptTokens = 0
}
if !currentPlanMode {
for _, mt := range mcpTools {
b.MCPToolsTokens += estimateToolTokens(ctx, mt)
}
}
if len(toolsForMode) > 0 {
total := 0
for _, at := range toolsForMode {
total += estimateToolTokens(ctx, at)
}
b.SystemToolsTokens = total - b.MCPToolsTokens
if b.SystemToolsTokens < 0 {
b.SystemToolsTokens = 0
}
}
return b
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/command/web.go` around lines 449 - 471, The breakdownFn function
always estimates context tokens using systemPrompt and buildAllTools(currentCM),
but it should dynamically select between systemPrompt with
buildAllTools(currentCM) for normal mode versus planPrompt with buildPlanTools()
for plan mode. Update the breakdownFn function to check if plan mode is active
and use the appropriate prompt and tools building function to accurately reflect
the actual context being used by the agent in the current mode. This will ensure
b.SystemPromptTokens and b.SystemToolsTokens are correctly calculated regardless
of whether plan mode is enabled.

Comment thread internal/config/config.go
Comment on lines +455 to +458
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the project-standard wrapped error prefix.

Line [457] uses a free-form wrapped error string; use the repo convention fmt.Errorf("tool_name: %w", err) for non-tool code.

Suggested change
-		return "", fmt.Errorf("failed to get home directory: %w", err)
+		return "", fmt.Errorf("usage_dir: %w", err)

As per coding guidelines, **/*.go: Use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("usage_dir: %w", err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/config.go` around lines 455 - 458, The fmt.Errorf call in the
error handling block after os.UserHomeDir() is not following the project
convention for wrapped errors. Replace the current free-form error message
"failed to get home directory: %w" with the standard format "tool_name: %w"
(where tool_name is the appropriate identifier for this module), maintaining the
wrapped error pattern with %w and the captured err variable.

Source: Coding guidelines

Comment on lines +161 to +164
// CacheObserved reports whether any cache-read tokens have been seen, used to
// distinguish "cache hit rate is 0%" from "this provider never reports caching".
func (t *TokenUsage) CacheObserved() bool {
return atomic.LoadInt64(&t.CachedTokens) > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Cache support detection is tied to cache hits instead of cache reporting.

CacheObserved() (Line 163) currently returns true only when cached tokens are > 0. That marks providers as “unsupported” for sessions where caching metadata is reported but all calls happen to have zero cache hits, which violates the “never reports caching data” behavior target.

Suggested fix direction
 type AddParams struct {
   Prompt     int
   Completion int
   Total      int
   Cached     int
   Reasoning  int
   CacheWrite int
+  CacheReported bool
 }

 func extractUsage(u openai.Usage) AddParams {
   p := AddParams{
     Prompt:     u.PromptTokens,
     Completion: u.CompletionTokens,
     Total:      u.TotalTokens,
   }
   if u.PromptTokensDetails != nil {
     p.Cached = u.PromptTokensDetails.CachedTokens
+    p.CacheReported = true
   }
   ...
 }

 type TokenUsage struct {
   ...
+  cacheObserved int64
 }

 func (t *TokenUsage) Add(p AddParams) {
   ...
+  if p.CacheReported {
+    atomic.StoreInt64(&t.cacheObserved, 1)
+  }
 }

 func (t *TokenUsage) CacheObserved() bool {
-  return atomic.LoadInt64(&t.CachedTokens) > 0
+  return atomic.LoadInt64(&t.cacheObserved) == 1
 }

 func (t *TokenUsage) Reset() {
   ...
+  atomic.StoreInt64(&t.cacheObserved, 0)
 }

Also applies to: 280-301

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/model/chatmodel.go` around lines 161 - 164, The CacheObserved()
method in TokenUsage incorrectly uses CachedTokens count as the indicator for
whether cache support was detected, but this conflates actual cache hits with
cache reporting capability. A provider may report caching metadata but have zero
cache hits in a session, which should still count as cache being observed.
Refactor CacheObserved() to check a separate flag or counter that tracks whether
caching information was ever reported by the provider, independent of the actual
cached token count. Also apply the same fix to any related cache observation
logic in the 280-301 line range, ensuring all cache support detection is based
on whether the provider reports caching data rather than on whether cached
tokens are greater than zero.

Comment thread internal/team/manager.go
Comment on lines +757 to +775
// Roll this teammate's per-turn token delta into the global usage log under
// the leader's session so team work counts toward global stats.
if state.TokenUsage != nil && m.deps.LeaderSessionUUID != "" {
full := state.TokenUsage.GetFull()
delta := full.Minus(state.LastUsage)
state.LastUsage = full
if delta.TotalTokens > 0 {
usage.RecordEvent(usage.Event{
Session: m.deps.LeaderSessionUUID,
Model: state.Model,
Prompt: delta.PromptTokens,
Completion: delta.CompletionTokens,
Cached: delta.CachedTokens,
Reasoning: delta.ReasoningTokens,
CacheWrite: delta.CacheWriteTokens,
Total: delta.TotalTokens,
Calls: delta.CallCount,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Populate Model and Project when recording teammate deltas.

Model is sourced from state.Model, which is empty on the default-model path, and Project is not set. Those turns still increase totals but get dropped from by_model / by_project analytics.

Suggested patch
 	if state.TokenUsage != nil && m.deps.LeaderSessionUUID != "" {
 		full := state.TokenUsage.GetFull()
 		delta := full.Minus(state.LastUsage)
 		state.LastUsage = full
 		if delta.TotalTokens > 0 {
+			modelName := state.Model
+			if modelName == "" && state.Recorder != nil {
+				modelName = state.Recorder.Model()
+			}
 			usage.RecordEvent(usage.Event{
 				Session:    m.deps.LeaderSessionUUID,
-				Model:      state.Model,
+				Project:    cwd,
+				Model:      modelName,
 				Prompt:     delta.PromptTokens,
 				Completion: delta.CompletionTokens,
 				Cached:     delta.CachedTokens,
 				Reasoning:  delta.ReasoningTokens,
 				CacheWrite: delta.CacheWriteTokens,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/team/manager.go` around lines 757 - 775, The usage.Event being
recorded in the token delta tracking is missing proper population of the Model
and Project fields. The Model field relies on state.Model which can be empty on
the default-model path, and Project is not set at all, causing analytics to drop
these records from by_model and by_project breakdowns. Enhance the
usage.RecordEvent call by ensuring Model has a fallback value when state.Model
is empty and add the Project field to the usage.Event struct with the
appropriate project information, likely obtained from the manager's dependencies
or team state context.

Comment thread internal/web/usage.go
Comment on lines +133 to +134
events, _ := store.Load("")
sel := make([]usage.Event, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not ignore historical usage load errors.

Ignoring store.Load failures silently turns I/O/corruption problems into zeroed task stats, which is misleading for users and hard to debug.

Suggested patch
-	events, _ := store.Load("")
+	events, err := store.Load("")
+	if err != nil {
+		config.Logger().Printf("[usage] task stats load failed: %v", err)
+		http.Error(w, "failed to load task usage stats", http.StatusInternalServerError)
+		return
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/usage.go` around lines 133 - 134, The store.Load function call
is ignoring its error return value using a blank identifier, which masks I/O and
corruption problems. Instead of discarding the error with _, check the error
returned from store.Load("") and handle it appropriately by either logging the
error and returning early or implementing suitable error recovery logic that
prevents the function from continuing with uninitialized or incorrect data.

Comment on lines +29 to +36
// Context-fill ring on the composer: the orange arc fills with the % of the
// context window in use, turning red as it approaches the limit.
const ctxRingCirc = 2 * Math.PI * 6.4
const ctxRingOffset = computed(() => {
const p = Math.min(100, Math.max(0, store.tokenPercentage))
return ctxRingCirc * (1 - p / 100)
})
const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '#E24B4A' : 'var(--color-primary)'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace hardcoded ring warning color with a design token.

The #E24B4A literal bypasses theme tokens and violates the color-token contract.

Suggested patch
-const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '`#E24B4A`' : 'var(--color-primary)'))
+const ctxRingColor = computed(() =>
+  store.tokenPercentage >= 90 ? 'var(--color-destructive)' : 'var(--color-primary)')

As per coding guidelines, "Every color must come from a CSS custom property defined in src/styles/tokens.css. Never hardcode hex/rgb/#fff/white in .vue or .css."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Context-fill ring on the composer: the orange arc fills with the % of the
// context window in use, turning red as it approaches the limit.
const ctxRingCirc = 2 * Math.PI * 6.4
const ctxRingOffset = computed(() => {
const p = Math.min(100, Math.max(0, store.tokenPercentage))
return ctxRingCirc * (1 - p / 100)
})
const ctxRingColor = computed(() => (store.tokenPercentage >= 90 ? '#E24B4A' : 'var(--color-primary)'))
// Context-fill ring on the composer: the orange arc fills with the % of the
// context window in use, turning red as it approaches the limit.
const ctxRingCirc = 2 * Math.PI * 6.4
const ctxRingOffset = computed(() => {
const p = Math.min(100, Math.max(0, store.tokenPercentage))
return ctxRingCirc * (1 - p / 100)
})
const ctxRingColor = computed(() =>
store.tokenPercentage >= 90 ? 'var(--color-destructive)' : 'var(--color-primary)')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ChatInput.vue` around lines 29 - 36, The ctxRingColor
computed property uses a hardcoded hex color value `#E24B4A` for the warning state
when token percentage is >= 90, which violates the color-token contract. Replace
this hardcoded hex value with an appropriate CSS custom property (design token)
from src/styles/tokens.css that represents a warning or error state color.
Update the ctxRingColor computed property to reference this design token using
var() syntax instead of the literal hex value.

Source: Coding guidelines

border-radius: var(--radius-md);
background: var(--color-background);
border: 1px solid var(--color-border);
box-shadow: var(--elevation-popover, 0 8px 24px rgba(0, 0, 0, 0.16));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use token-based shadow fallback instead of hardcoded RGBA.

The current fallback hardcodes color values and breaks the tokenized theme contract.

Suggested patch
-  box-shadow: var(--elevation-popover, 0 8px 24px rgba(0, 0, 0, 0.16));
+  box-shadow: var(--elevation-popover, var(--shadow-lg));

As per coding guidelines, "Every color must come from a CSS custom property defined in src/styles/tokens.css. Never hardcode hex/rgb/#fff/white in .vue or .css."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ContextCapacityPopup.vue` at line 144, The box-shadow
property in ContextCapacityPopup.vue contains a hardcoded RGBA color fallback
value (rgba(0, 0, 0, 0.16)) which violates the tokenized theme contract. Replace
the hardcoded rgba color value in the box-shadow fallback with a CSS custom
property token defined in src/styles/tokens.css. If a suitable shadow token does
not already exist, add a new token to the tokens.css file that defines the
appropriate shadow value, then reference that token in the var() function
fallback instead of the hardcoded color.

Source: Coding guidelines

Comment on lines +141 to +145
function cellTitle(c: HeatCell): string {
if (c.future) return ''
if (c.tokens <= 0) return `${fmtDayLabel(c.date)} · ${t('settings.usageStats.noActivity')}`
return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} tokens · ${c.turns} ${t('settings.usageStats.turnsUnit')}`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the hardcoded "tokens" unit in tooltip labels.

These strings stay English even when the locale changes. Please move the unit to i18n keys (parallel to turnsUnit) and reference it here.

Suggested patch
 function cellTitle(c: HeatCell): string {
   if (c.future) return ''
   if (c.tokens <= 0) return `${fmtDayLabel(c.date)} · ${t('settings.usageStats.noActivity')}`
-  return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} tokens · ${c.turns} ${t('settings.usageStats.turnsUnit')}`
+  return `${fmtDayLabel(c.date)} · ${fmtCompact(c.tokens)} ${t('settings.usageStats.tokensUnit')} · ${c.turns} ${t('settings.usageStats.turnsUnit')}`
 }
 ...
 function barTitle(d: UsageDayBucket): string {
-  return `${fmtDayLabel(d.date)} · ${fmtCompact(d.tokens)} tokens · ${d.turns} ${t('settings.usageStats.turnsUnit')}`
+  return `${fmtDayLabel(d.date)} · ${fmtCompact(d.tokens)} ${t('settings.usageStats.tokensUnit')} · ${d.turns} ${t('settings.usageStats.turnsUnit')}`
 }

Also applies to: 151-153

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/UsageStatsPanel.vue` around lines 141 - 145, The hardcoded
"tokens" unit string in the cellTitle function is not localized and will remain
in English regardless of locale changes. Replace the hardcoded "tokens" string
with a translation reference using the i18n pattern (similar to how
`t('settings.usageStats.turnsUnit')` is used for the turns unit). Create a new
i18n key like `settings.usageStats.tokensUnit` and update both the cellTitle
function and the other locations mentioned in lines 151-153 to use this
localization key instead of the hardcoded English string.

Comment thread web/src/stores/usage.ts
Comment on lines +18 to +25
async function fetchTaskStats(uuid: string) {
if (!uuid) return
taskLoading.value = true
try {
taskStats.value = await api.taskStats(uuid)
} catch {
taskStats.value = null
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear previous taskStats before fetching a new session.

taskStats is kept from the last session until the new request returns, so the context popup can momentarily show stale data after switching sessions.

Suggested patch
 async function fetchTaskStats(uuid: string) {
-  if (!uuid) return
+  taskStats.value = null
+  if (!uuid) return
   taskLoading.value = true
   try {
     taskStats.value = await api.taskStats(uuid)
   } catch {
     taskStats.value = null
   } finally {
     taskLoading.value = false
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/stores/usage.ts` around lines 18 - 25, In the fetchTaskStats
function, the previous taskStats value remains visible until the new API request
completes, causing stale data to display when switching sessions. To fix this,
clear the taskStats value to null immediately after setting taskLoading.value to
true and before making the api.taskStats call, ensuring no stale data is shown
while the new session's data is being fetched.

@cnjack cnjack merged commit b16b732 into main Jun 22, 2026
3 checks passed
@cnjack cnjack deleted the feat/usage-statistics branch June 22, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant